#!/usr/bin/env python3
#
# Copyright 2015-2025 the openage authors. See copying.md for legal info.

"""
Runs Cython on all modules that were listed via add_cython_module.
"""

import argparse
import os
import sys
from contextlib import redirect_stdout
from multiprocessing import cpu_count
from pathlib import Path

from Cython.Build import cythonize


class LineFilter:
    """ Proxy for a stream (default stdout) to filter out whole unwanted lines """
    # pylint: disable=too-few-public-methods
    def __init__(self, stream=None, filters=None):
        self.stream = stream or sys.stdout
        self.filters = filters or []
        self.buf = ""

    def __getattr__(self, attr_name):
        return getattr(self.stream, attr_name)

    def __enter__(self):
        return self

    def __exit__(self, *args):
        self.stream.write(self.buf)
        self.buf = ""

    def write(self, data):
        """
        Writes to output stream, buffered line-wise,
        omitting lines given in to constructor in filter
        """
        self.buf += data
        lines = self.buf.split('\n')
        for line in lines[:-1]:
            if not any(f(line) for f in self.filters):
                self.stream.write(line + '\n')
        self.buf = lines[-1]


class CythonFilter(LineFilter):
    """ Filters output of cythonize for useless warnings """
    # pylint: disable=too-few-public-methods
    def __init__(self):
        filters = [
            lambda x: 'put "# distutils: language=c++" in your .pyx or .pxd file(s)' in x,
            lambda x: x.startswith('Compiling ') and x.endswith(' because it changed.')
        ]
        super().__init__(filters=filters)


def read_list_from_file(filename):
    """ Reads a semicolon-separated list of file entires """
    with open(filename, encoding='utf8') as fileobj:
        data = fileobj.read().strip()

    return [Path(filename).resolve() for filename in data.split(';')]


def remove_if_exists(filename):
    """ Deletes the file (if it exists) """
    if filename.is_file():
        print(filename.relative_to(os.getcwd()))
        filename.unlink()


def cythonize_wrapper(modules, force_optimized_lib = False, **kwargs):
    """ Calls cythonize, filtering useless warnings """
    bin_dir, bin_modules = kwargs['build_dir'], []
    src_dir, src_modules = Path.cwd(), []

    for module in modules:
        if Path(bin_dir) in module.parents:
            bin_modules.append(str(module.relative_to(bin_dir)))
        else:
            src_modules.append(str(module.relative_to(src_dir)))

    with CythonFilter() as cython_filter:
        with redirect_stdout(cython_filter):
            if src_modules:
                cythonize(src_modules, **kwargs)
                if sys.platform == 'win32' and force_optimized_lib:
                    win_use_optimized_lib_python(src_modules, bin_dir)

            if bin_modules:
                os.chdir(bin_dir)
                cythonize(bin_modules, **kwargs)
                if sys.platform == 'win32' and force_optimized_lib:
                    win_use_optimized_lib_python(bin_modules, bin_dir)
                os.chdir(src_dir)


def win_use_optimized_lib_python(modules, path):
    """
    Add an #ifdef statement in cythonized .cpp files to temporarily undefine _DEBUG before
    #include "Python.h"

    This function modifies the generated C++ files from Cython to prevent linking to
    the debug version of the Python library on Windows. The debug version of the
    Python library cannot import Python libraries that contain extension modules.
    see: https://github.com/python/cpython/issues/127619 (To unserstand the problem)
    see: https://stackoverflow.com/a/59566420 (To understand the soloution)
    """

    for module in modules:
        module = str(module)
        if path:
            module = path + "\\" + module
        module = module.removesuffix(".py").removesuffix(".pyx")
        module = module + ".cpp"
        with open(module, "r", encoding='utf8') as file:
            text = file.read()
            if not text.count("OPENAGE: UNDEF_DEBUG_INSERTED"):
                text = text.replace(
                    '#include "Python.h"',
                    (
                        "\n\n// OPENAGE: UNDEF_DEBUG_INSERTED\n"
                        "// Avoid linking to the debug version of the Python library on Windows\n\n"
                        "#ifdef _DEBUG\n"
                        "#define _DEBUG_WAS_DEFINED\n"
                        "#undef _DEBUG\n#endif\n"
                        "#include \"Python.h\"\n"
                        "#ifdef _DEBUG_WAS_DEFINED\n"
                        "#define _DEBUG\n"
                        "#undef _DEBUG_WAS_DEFINED\n"
                        "#endif\n\n"
                        "// OPENAGE: UNDEF_DEBUG_INSERTED\n\n"
                    ),
                    1
                )
        with open(module, "w", encoding='utf8') as file:
            file.write(text)


def main():
    """ CLI entry point """
    cli = argparse.ArgumentParser()
    cli.add_argument("module_list", help=(
        "Module list file (semicolon-separated)."
    ))
    cli.add_argument("embedded_module_list", help=(
        "Embedded module list file (semicolon-separated).\n"
        "Modules in this list are compiled with the --embed option."
    ))
    cli.add_argument("depends_list", help=(
        "Dependency list file (semicolon-separated).\n"
        "Contains all .pxd and other files that may get included.\n"
        "Used to verify that all dependencies are properly listed "
        "in the CMake build configuration."
    ))
    cli.add_argument("--clean", action="store_true", help=(
        "Clean compilation results and exit."
    ))
    cli.add_argument("--build-dir", help=(
        "Build output directory to generate the cpp files in."
        "note: this is also added for module search path."
    ))
    cli.add_argument("--memcleanup", type=int, default=0, help=(
        "Generate memory cleanup code to make valgrind happy:\n"
        "0: nothing, 1+: interned objects,\n"
        "2+: cdef globals, 3+: types objects"
    ))
    cli.add_argument("--threads", type=int, default=cpu_count(),
                     help="number of compilation threads to use")
    cli.add_argument("--force_optimized_lib", action="store_true",
                     help= "edit compiled .cpp files to link to optimized version of python libary")
    args = cli.parse_args()

    # cython emits warnings on using absolute paths to modules
    # https://github.com/cython/cython/issues/2323
    modules = read_list_from_file(args.module_list)
    embedded_modules = read_list_from_file(args.embedded_module_list)
    depends = set(read_list_from_file(args.depends_list))

    if args.clean:
        for module in modules + embedded_modules:
            rel_module = module.relative_to(Path.cwd())
            build_module = args.build_dir / rel_module
            remove_if_exists(build_module.with_suffix('.cpp'))
            remove_if_exists(build_module.with_suffix('.html'))
        sys.exit(0)

    from Cython.Compiler import Options
    Options.annotate = True
    Options.fast_fail = True
    Options.generate_cleanup_code = args.memcleanup
    Options.cplus = 1

    # build cython modules (emits shared libraries)
    cythonize_args = {
        'compiler_directives': {'language_level': 3},
        'build_dir': args.build_dir,
        'include_path': [args.build_dir],
        'nthreads': args.threads
    }

    # this is deprecated, but still better than
    # writing funny lines at the head of each file.
    cythonize_args['language'] = 'c++'

    cythonize_wrapper(modules, args.force_optimized_lib, **cythonize_args)

    # build standalone executables that embed the py interpreter
    Options.embed = "main"

    cythonize_wrapper(embedded_modules, args.force_optimized_lib, **cythonize_args)

    # verify depends
    from Cython.Build.Dependencies import _dep_tree

    depend_failed = False
    # TODO figure out a less hacky way of getting the depends out of Cython
    # pylint: disable=no-member, protected-access
    for module, files in _dep_tree.__cimported_files_cache.items():
        for filename in files:
            if not filename.startswith('.'):
                # system include starts with /
                continue

            if os.path.realpath(os.path.abspath(filename)) not in depends:
                print("\x1b[31mERR\x1b[m unlisted dependency: " + filename)
                depend_failed = True

    if depend_failed:
        sys.exit(1)


if __name__ == '__main__':
    main()
